54. JVM

JVM体系结构

灰色部分没有垃圾回收,垃圾回收出现在橙色部分,而且 99% 的垃圾回收都在堆当中。

灰色的部分(虚拟机栈,本地方法栈,程序计数器)是线程隔离的数据区。其他部分都是线程共享的。

类加载器

负责加载 class 文件,class 文件在文件开头有特定的文件标示,并且 ClassLoader 只负责 class 文件的加载,至于它是否可以运行,则由 Execution Engine 决定。类加载器有,虚拟机自带的加载器,启动类加载器(Bootstrap)扩展类加载器(Extension),应用程序类加载器(AppClassLoader),用户自定义加载器(继承 ClassLoader 自己实现的)

程序计数器

程序计数器(Progam Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的
字节码的行号指示器。在 Java 虛拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器
的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处
理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于 Java 虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一
个确定的时刻, 一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因
此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程
之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地
址;如果正在执行的是本地(Native) 方法,这个计数器值则应为空(Undefined) 。此内存区域是唯
一个在《Java虚拟机规范》中没有规定任何 OutOfMemoryError 情况的区域。

虚拟机栈(Java 栈)

在线程创建的时候创建,生命周期是跟随线程的生命周期的,对于栈来说不存在垃圾回收的问题。

里面主要存放8种基本类型的变量 + 对象的引用变量 + 实例方法

本地方法栈

它的具体做法是在本地方法栈中登记 native 方法,在执行引擎执行的时候加载本地方法库

方法区

方法区是所有线程共享的,所有字段和方法的字节码,以及一些特殊方法如构造函数,接口代码也是定义在这里。简单来说就是所有定义的方法的信息都保存在这里。

静态变量,常量,类信息(构造函数,接口定义)运行时常量池 都存在这里,但是实例变量不在这里,实例变量在堆中。

一个 JVM 实例只存在一个堆内存,堆内存的大小是可以调节的。类载器读取了类文件后, 需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行,堆内存分为三部分,分别是 新生区养老区永久区

新生区是类的诞生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。新生区又分为两部分: 伊甸区(Eden space)和幸存者区(Survivor pace), 所有的类都是在伊甸区被 new 出来的。幸存区有两个: 0区(Survivor 0 space)和1区(Survivor 1 space)。当伊甸园的空间用完时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。然后将伊甸园中的剩余对象移动到幸存0区。若幸存0区也满了,再对该区进行垃圾回收,然后移动到1区。那如果1区也满了呢?再次垃圾回收,满足条件后再移动到养老区。若养老区也满了,那么这个时候将产生 MajorGC(FullGC),进行养老区的内存清理。若养老区执行了 Full GC 之后发现依然无法进行对象的保存,就会产生 OOM 异常 “OutOfMemoryError” 。

如果出现 java.lang.OutOfMemoryError: Java heap space异常,说明 Java虚拟机的堆内存不够。原可能是 Java虚拟机的堆内存设置不够,可以通过参数 -Xms、-Xmx 来调整(如果默认不调整,Java 虚拟机启动占用的内存大小为机器内存的 64/1,最大可达机器内存 4/1。可以通过-Xms 来设置最小内存,-Xmx 类设置最大内存 )。或者是代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。

永久区是一个常驻内存区域,用于存放 JDK 自身所携带的 Class, Interface 的元数据,也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭 JVM 才会释放此区域所占用的内存。如果出现 java. lang. OutOfMemoryError: PermGen space,说明是 Java 虚拟机对永久代 Perm 内存设置不够。一般出现这种情况,都是程序启动需要加载大量的第三方jar包。例如:在一个 Tomcat 下部署了太多的应用。或者大量动态反射生成的类不断被加载,最终导致 Perm 区被占满。在 Java 1.7 及 1.7 以前是存在 永久区 的,从 1.8 开始就没有了,取而代之的是元空间。

可以导出 JVM 的dump 文件进行分析,查看到底是哪里发生了 oom 问题。

执行引擎

负责解释命令,将命令提交给操作系统执行。

本地库接口

本地接口的作用是融合不同的编程语言为 Java 所用,它的具体做法是在本地方法栈中登记 native 方法,在执行引擎执行的时候加载本地方法库

GC

GC,又叫做分带收集算法。表现为频繁的收集新生区,较少的收集养老区,基本不动 永久区

GC 的四种算法

  1. 引用计数算法:引用计数为 0 就会被回收,但是存在循环引用的问题。

  2. 复制算法:这种算法在新生区中被使用,HotSpot JVM把年轻代(新生区)分为了三部分:1个 Eden 区和2个Survivor区(分别叫from和to)。默认比例为 8:1:1, 一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC 后,如果仍然存活,将会被移到 Survivor 区。对象 在Survivor区中每熬过一次 Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代(养老区)中。因为年轻代中的对象基本都是朝生夕死的 (90%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold 来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到 “To” 区域。经过这次 GC 后,Eden 区和 From区已经被清空。这个时候,“From” 和 “To” 会交换他们的角色,也就是新的 “To” 就是上次 GC 前的 “From”,新的 “From” 就是上次GC前的 “To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

    因为Eden区对象一般存活率较低,一般的,使用两块10%的内存作为空闲和活动区间,而另外80%的内存,则是用来给新建对象分配内存的。一旦发生GC,将10%的from活动区间与另外80%中存活的eden对象转移到10%的to空闲区间,接下来,将之前90%的内存全部释放,以此类推。

    复制算法它的缺点也是相当明显的。 它浪费了一半的内存,这太要命了。 如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。 所以从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服50%内存的浪费。

  1. 标记清除:标记清除在养老区中被使用。当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。标记:从引用根节点开始标记所有被引用的对象。标记的过程其实就是遍历所有的GC Roots,然后将所有GC Roots可达的对象 标记为存活的对象。 清除:遍历整个堆,把未标记的对象清除。

    它的缺点就是效率比较低(递归与全堆对象遍历),而且在进行GC的时候,需要停止应用程序,这会导致用户体验非常差劲。其次,主要的缺点则是这种方式清理出来的空闲内存是不连续的,这点不难理解,我们的死亡对象都是随即的出现在内存的各个角落的,现在把它们清除之后,内存的布局自然会乱七八糟。而为了应付这一点,JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。

  1. 标记压缩:标记压缩在养老区中被使用。在整理压缩阶段,不再对标记的对像做回收,而是通过所有存活对像都向一端移动,然后直接清除边界以外的内存。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。 标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。

    标记/整理算法唯一的缺点就是效率也不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。

  2. 标记清除压缩:是标记清除和标记压缩的结合。